Spring Security | Note-16

Spring Security Note-16


基于JWT实现SSO单点登录


问题

在实际生活的应用中,我们举个例子而言,对于淘宝和天猫这两个完全不同的域名,是两个完全不同的服务器上;

我们在淘宝进行登录的时候,跳转到了一个域名为

login.taobao.com

意味着,服务器又换了一个,到此已经出现了三个服务器;

登录后,页面又跳转回到了淘宝,此时我们重新刷新天猫,天猫也以直接完成了登录状态;

虽然这两个网站是独立的域名,独立的服务器,但是在其中一个网站登录,在另一个网站上实现了登录;


步骤流程

应用A(淘宝) 应用B(天猫) 和认证服务器(登录服务器);

0.用户访问请求登录应用A;

1.应用A将会向认证服务器发起请求授权的请求;

2.此时在认证服务器中获取认证并授权(登录)之后;

3.认证服务区将会返回一个授权码给应用A;

4.获得授权码后,应用A会再次请求认证服务器请求令牌;

5.认证服务器接受请求,返回JWT配置下的令牌,返回给应用A;

6.应用A获得JWT令牌后,进行解析,并且完成登录;

7.当用户访问另一个系统(应用B);

8.应用B也会去认证服务器请求授权;

9.但是在认证服务器中,已存在授权记录,那么认证服务器是知道用户是谁;

10.完成整个OAuth流程,并且返回应用B一个JWT令牌(不同于应用A);

11.应用B获取到认证服务器返回给应用B的JWT进行解析并登录;

12.真正的业务是部署在一个独立的资源服务器,我们只需要使用JWT访问资源服务器即可;

PS:尽管应用A和应用B的JWT不同,但是解析出来的用户信息是一致的;


实现

我们需要重新新建项目sso-demo,在sso这个项目中,我们需要对认证服务器进行一个重新的构造,这个认证服务器是基于浏览器,session的技术;

创建四个项目;

认证服务器配置SSOAuthorizationServerConfig
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* 认证服务器
* @Author: REX
* @Date: Create in 14:34 2018/9/4
*/
@Configuration
@EnableAuthorizationServer
public class SSOAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 配置两个应用
clients.inMemory()
.withClient("app1").secret("appsecret1")
.authorizedGrantTypes("authorization_code", "refresh_token").scopes("all")
.and()
.withClient("app2").secret("appsecret2")
.authorizedGrantTypes("authorization_code", "refresh_token").scopes("all");
}

// 安全配置
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
// 授权表达式
security.tokenKeyAccess("isAuthenticated()");
}

// 配置入口
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(jwtTokenStore()).accessTokenConverter(jwtAccessTokenConverter());
}

// 配置JWT
@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
// 指定密签
converter.setSigningKey("REX");
return converter;
}
}
配置文件
1
2
3
server.port=9999
server.context-path=/server
security.user.password=123123

应用A

1
2
3
4
5
6
7
8
9
10
11
12
@SpringBootApplication
@RestController
@EnableOAuth2Sso
public class SSOClient1Application {
public static void main(String[] args) {
SpringApplication.run(SSOClient1Application.class,args);
}
@GetMapping("/user")
public Authentication user(Authentication user){
return user;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
# OAuth认证(与服务器配置要一致)
security.oauth2.client.client-id=app1
security.oauth2.client.client-secret=appsecret1
# 认证服务器
security.oauth2.client.user-authorization-uri=http://127.0.0.1:9999/server/oauth/authorize
# 返回授权码
security.oauth2.client.access-token-uri=http://127.0.0.1:9999/server/oauth/token
# 解析的密钥URL
security.oauth2.resource.jwt.key-uri=http://127.0.0.1:9999/server/oauth/token_key
# SERVER
server.port=8080
server.context-path=/client1

应用B

1
2
3
4
5
6
7
8
9
10
11
12
# OAuth认证(与服务器配置要一致)
security.oauth2.client.client-id=app2
security.oauth2.client.client-secret=appsecret2
# 认证服务器
security.oauth2.client.user-authorization-uri=http://127.0.0.1:9999/server/oauth/authorize
# 返回授权码
security.oauth2.client.access-token-uri=http://127.0.0.1:9999/server/oauth/token
# 解析的密钥URL
security.oauth2.resource.jwt.key-uri=http://127.0.0.1:9999/server/oauth/token_key
# SERVER
server.port=8060
server.context-path=/client2

测试

1.首先访问127.0.0.1:8080/client1/index.html

2.跳转登录认证(9999)

username:user

password:123123

3.是否授权给APP1访问受保护的资源?

4.得到授权后返回,此时已获得认证服务器的授权

5.访问/user,获取用户信息

6.访问Client2,这时候,认证服务器已经知道我的用户信息,不需要重新登录,只需要授权即可

7.此时即可完成Client1和Client2页面之间任意的跳转

8.这时候我们会发现Client1和Client2访问/user所获得的tokenValue是不同的,但是用户信息是一样的;


改善

登录页面和A跳转B的隐藏授权;

登录

在Server项目中,重新configure为表单登录的方式;

1
2
3
4
5
6
7
@Configuration
public class SSOSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin().and().authorizeRequests().anyRequest().authenticated();
}
}

不将用户名和密码写死,通过重新实现UserDetailsService的方式,从数据库中读写用户名和密码;

1
2
3
4
5
6
7
@Component
public class SSOUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return new User(username,"", AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"));
}
}

覆盖

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class SSOSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
}

-

授权

找到授权的表单的页面在WhitelabelApprovalEndpoint类,在进入时直接跳转授权,不需要点击;

重新写一个WhitelabelApprovalEndpoint,将@FrameworkEndpoint改为@RestController进行处理;

并且复制一份SpelViewSsoSpelView

我们直接将confirmationForm提交,并且隐藏<body></body>

1
2
3
4
5
6
7
8
9
10
11
12
13
<html>
<body>
<div style='display:none;'>
<h1>OAuth Approval</h1>
<p>Do you authorize '${authorizationRequest.clientId}' to access your protected resources?</p>
<form id='confirmationForm' name='confirmationForm' action='${path}/oauth/authorize' method='post'>
<input name='user_oauth_approval' value='true' type='hidden'/>%csrf%%scopes%<label><input name='authorize' value='Authorize' type='submit'/></label>
</form>
%denial%
</div>
<script>document.getElementById('confirmationForm').submit()</script>
</body>
</html>"